| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351 |
- import User, { USER_ROLES } from "@/models/user";
- import { getDb } from "@/lib/db";
- import { getSession } from "@/lib/auth/session";
- import { requireUserManagement } from "@/lib/auth/permissions";
- import {
- withErrorHandling,
- json,
- badRequest,
- unauthorized,
- notFound,
- } from "@/lib/api/errors";
- export const dynamic = "force-dynamic";
- const BRANCH_RE = /^NL\d+$/;
- const OBJECT_ID_RE = /^[a-f0-9]{24}$/i;
- const USERNAME_RE = /^[a-z0-9][a-z0-9._-]{2,31}$/; // 3..32, conservative
- const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
- const ALLOWED_ROLES = new Set(Object.values(USER_ROLES));
- const ALLOWED_UPDATE_FIELDS = Object.freeze([
- "username",
- "email",
- "role",
- "branchId",
- "mustChangePassword",
- ]);
- function isPlainObject(value) {
- return Boolean(value && typeof value === "object" && !Array.isArray(value));
- }
- function isNonEmptyString(value) {
- return typeof value === "string" && value.trim().length > 0;
- }
- function normalizeUsername(value) {
- return String(value || "")
- .trim()
- .toLowerCase();
- }
- function normalizeEmail(value) {
- return String(value || "")
- .trim()
- .toLowerCase();
- }
- function normalizeBranchId(value) {
- return String(value || "")
- .trim()
- .toUpperCase();
- }
- function toIsoOrNull(value) {
- if (!value) return null;
- try {
- return new Date(value).toISOString();
- } catch {
- return null;
- }
- }
- function toSafeUser(doc) {
- return {
- id: String(doc?._id),
- username: typeof doc?.username === "string" ? doc.username : "",
- email: typeof doc?.email === "string" ? doc.email : "",
- role: typeof doc?.role === "string" ? doc.role : "",
- branchId: doc?.branchId ?? null,
- mustChangePassword: Boolean(doc?.mustChangePassword),
- createdAt: toIsoOrNull(doc?.createdAt),
- updatedAt: toIsoOrNull(doc?.updatedAt),
- };
- }
- function pickDuplicateField(err) {
- if (!err || typeof err !== "object") return null;
- const keyValue =
- err.keyValue && typeof err.keyValue === "object" ? err.keyValue : null;
- if (keyValue) {
- const keys = Object.keys(keyValue);
- if (keys.length > 0) return keys[0];
- }
- const keyPattern =
- err.keyPattern && typeof err.keyPattern === "object"
- ? err.keyPattern
- : null;
- if (keyPattern) {
- const keys = Object.keys(keyPattern);
- if (keys.length > 0) return keys[0];
- }
- return null;
- }
- export const PATCH = withErrorHandling(
- async function PATCH(request, ctx) {
- const session = await getSession();
- if (!session) {
- throw unauthorized("AUTH_UNAUTHENTICATED", "Unauthorized");
- }
- requireUserManagement(session);
- const { userId } = await ctx.params;
- if (!userId) {
- throw badRequest(
- "VALIDATION_MISSING_PARAM",
- "Missing required route parameter(s)",
- { params: ["userId"] },
- );
- }
- if (!OBJECT_ID_RE.test(String(userId))) {
- throw badRequest("VALIDATION_INVALID_FIELD", "Invalid userId", {
- field: "userId",
- value: userId,
- });
- }
- let body;
- try {
- body = await request.json();
- } catch {
- throw badRequest("VALIDATION_INVALID_JSON", "Invalid request body");
- }
- if (!isPlainObject(body)) {
- throw badRequest("VALIDATION_INVALID_BODY", "Invalid request body");
- }
- const hasUpdateField = Object.keys(body).some((k) =>
- ALLOWED_UPDATE_FIELDS.includes(k),
- );
- if (!hasUpdateField) {
- throw badRequest("VALIDATION_MISSING_FIELD", "Missing fields to update", {
- fields: [...ALLOWED_UPDATE_FIELDS],
- });
- }
- await getDb();
- const user = await User.findById(String(userId)).exec();
- if (!user) {
- throw notFound("USER_NOT_FOUND", "Not found", { userId: String(userId) });
- }
- const patch = {};
- // username (optional)
- if (Object.prototype.hasOwnProperty.call(body, "username")) {
- if (!isNonEmptyString(body.username)) {
- throw badRequest("VALIDATION_INVALID_FIELD", "Invalid username", {
- field: "username",
- value: body.username,
- });
- }
- const username = normalizeUsername(body.username);
- if (!USERNAME_RE.test(username)) {
- throw badRequest("VALIDATION_INVALID_FIELD", "Invalid username", {
- field: "username",
- value: username,
- pattern: String(USERNAME_RE),
- });
- }
- const existing = await User.findOne({
- username,
- _id: { $ne: String(userId) },
- })
- .select("_id")
- .exec();
- if (existing) {
- throw badRequest(
- "VALIDATION_INVALID_FIELD",
- "Username already exists",
- {
- field: "username",
- value: username,
- },
- );
- }
- patch.username = username;
- }
- // email (optional)
- if (Object.prototype.hasOwnProperty.call(body, "email")) {
- if (!isNonEmptyString(body.email)) {
- throw badRequest("VALIDATION_INVALID_FIELD", "Invalid email", {
- field: "email",
- value: body.email,
- });
- }
- const email = normalizeEmail(body.email);
- if (!EMAIL_RE.test(email)) {
- throw badRequest("VALIDATION_INVALID_FIELD", "Invalid email", {
- field: "email",
- value: email,
- });
- }
- const existing = await User.findOne({
- email,
- _id: { $ne: String(userId) },
- })
- .select("_id")
- .exec();
- if (existing) {
- throw badRequest("VALIDATION_INVALID_FIELD", "Email already exists", {
- field: "email",
- value: email,
- });
- }
- patch.email = email;
- }
- // role (optional)
- if (Object.prototype.hasOwnProperty.call(body, "role")) {
- if (!isNonEmptyString(body.role)) {
- throw badRequest("VALIDATION_INVALID_FIELD", "Invalid role", {
- field: "role",
- value: body.role,
- allowed: Array.from(ALLOWED_ROLES),
- });
- }
- const role = String(body.role).trim();
- if (!ALLOWED_ROLES.has(role)) {
- throw badRequest("VALIDATION_INVALID_FIELD", "Invalid role", {
- field: "role",
- value: role,
- allowed: Array.from(ALLOWED_ROLES),
- });
- }
- patch.role = role;
- }
- // branchId (optional, can be null)
- if (Object.prototype.hasOwnProperty.call(body, "branchId")) {
- if (body.branchId === null) {
- patch.branchId = null;
- } else if (isNonEmptyString(body.branchId)) {
- patch.branchId = normalizeBranchId(body.branchId);
- } else {
- throw badRequest("VALIDATION_INVALID_FIELD", "Invalid branchId", {
- field: "branchId",
- value: body.branchId,
- pattern: "^NL\\d+$",
- });
- }
- }
- // mustChangePassword (optional)
- if (Object.prototype.hasOwnProperty.call(body, "mustChangePassword")) {
- if (typeof body.mustChangePassword !== "boolean") {
- throw badRequest(
- "VALIDATION_INVALID_FIELD",
- "Invalid mustChangePassword",
- {
- field: "mustChangePassword",
- value: body.mustChangePassword,
- },
- );
- }
- patch.mustChangePassword = body.mustChangePassword;
- }
- // --- Enforce role <-> branchId consistency --------------------------------
- const nextRole = patch.role ?? user.role;
- const nextBranchId = Object.prototype.hasOwnProperty.call(patch, "branchId")
- ? patch.branchId
- : (user.branchId ?? null);
- if (nextRole === USER_ROLES.BRANCH) {
- if (!isNonEmptyString(nextBranchId)) {
- throw badRequest(
- "VALIDATION_MISSING_FIELD",
- "Missing required fields",
- {
- fields: ["branchId"],
- },
- );
- }
- const normalized = normalizeBranchId(nextBranchId);
- if (!BRANCH_RE.test(normalized)) {
- throw badRequest("VALIDATION_INVALID_FIELD", "Invalid branchId", {
- field: "branchId",
- value: normalized,
- pattern: "^NL\\d+$",
- });
- }
- patch.branchId = normalized;
- } else {
- // For non-branch users, always clear branchId
- patch.branchId = null;
- }
- // --- Apply patch ----------------------------------------------------------
- if (Object.prototype.hasOwnProperty.call(patch, "username"))
- user.username = patch.username;
- if (Object.prototype.hasOwnProperty.call(patch, "email"))
- user.email = patch.email;
- if (Object.prototype.hasOwnProperty.call(patch, "role"))
- user.role = patch.role;
- if (Object.prototype.hasOwnProperty.call(patch, "branchId"))
- user.branchId = patch.branchId;
- if (Object.prototype.hasOwnProperty.call(patch, "mustChangePassword"))
- user.mustChangePassword = patch.mustChangePassword;
- try {
- await user.save();
- } catch (err) {
- if (err && err.code === 11000) {
- const field = pickDuplicateField(err) || "unknown";
- throw badRequest("VALIDATION_INVALID_FIELD", "Duplicate key", {
- field,
- });
- }
- throw err;
- }
- return json({ ok: true, user: toSafeUser(user) }, 200);
- },
- { logPrefix: "[api/admin/users/[userId]]" },
- );
|